查看原文
其他

云原生世界中的高端玩法:数据包标记

Arthur Chiao 云原生实验室 2021-05-26


更多奇技淫巧欢迎订阅博客:https://fuckcloudnative.io

前言

本文翻译自 2020 年 Joe Stringer 在 Linux Plumbers Conference 的一篇分享: Packet Mark In a Cloud Native World[1]。探讨一个在网络和安全领域非常重要但又讨论甚少的主题:skb mark。

skb mark 是打在内核数据包(skb )上的数字标记,例如,可能是一个 16bit 或 32bit 整数表示。这个 mark 只存在于每台主机内部,当包从网卡发出去之后,这个信息 就丢失了 —— 也就是说,它并没有存储在任何 packet header 中

skb mark 用于传递状态信息。在主机的网络处理路径上,网络应用(network applications)可以在一个地方给包打上 mark,稍后在另一个地方根据 mark 值对包进行相 应操作,据此可以实现 NAT、QoS、LoadBalancing 等功能。

这里的一个问题是:mark 是一个开放空间,目前还没有任何行业规范,因此任何应用 可以往里面写入任何值 —— 只要稍后它自己能正确解读就行了,但每个 skb 的 mark 只有一 份。显而易见,当主机内同时运行了多个网络应用并且它们都在使用 skb mark 时(例 如,kube-proxy + Cilium),就有可能发生冲突,导致包被莫名其妙地转发、丢弃或 修改等问题,因为它们彼此并不感知对方的 mark 语义。

由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

以下是译文。

准备这次分享时,我产生了一个疑问是:互联网是如何连接到一起的(how is the internet held together)?比如,是靠胶带(duct tape)吗?—— 这当然是开玩笑。

今天讨论的主题 —— skb mark —— 要比胶带严肃的多。另外,本文将聚焦在云原生( cloud native)领域,因为过去 2~5 年这一领域出现了很多新的网络插件(software plugins),正是它们在控制着现在的网络。

1. 背景

Linux 世界中的 mark

  • fw_mark

    我不确定这是 firewall mark 还是 forwarding mark 的缩写。 iptables 和内核路由层(routing layer)会用到这个 mark。

// include/linux/skbuff.h,代码来自 Kernel 4.19,下同。译注
      
struct sk_buff {
    ...
     union {
         __u32    mark;
         __u32    reserved_tailroom;
     };
     ...
 }
  • ct_mark

    连接跟踪(conntrack)的 mark,这个 mark 并没有打在 skb->mark 上,但使  用方式是类似的:先将信息存到 mark,到了用的地方再取出来,根据 mark 状态  进行相应处理。

  • skb_mark

    OVS 里面的一个 mark,虽然和 skb->mark 不是一个东西,但二者是强关联的。

  • SO_MARKinclude/uapi/asm-generic/socket.h

    用户空间 socket 层的 mark。应用层可以用 setsockopt() 将某些信息传递到  netfilter 和 tc 之类的子系统中。后面会看到使用案例。

  • xfrm_mark

    来自变换子系统(transform subsystem)。

// include/uapi/linux/xfrm.h
  
struct xfrm_mark {
    __u32           v; /* value */
    __u32           m; /* mask */
};
  • pkt_mark

    OVS 字段,引入的目的是对 OVS skb_mark 做通用化,因为后者用于 Linux,而  OVS 可能运行在非 Linux 机器上。

mark 有什么用?

说了这么多,那这些 mark 到底有什么用?—— 如果不设置,那它们就没什么用。

换句话说,mark 能发挥多少作用、完成哪些功能 ,全看应用怎么用它。比如当需要编程控制内核的处理行为时,就会和这些 mark 打交道。很多黑科技就源于此。

mark 注册中心

如果你开发了一个网络软件,流量收发都没问题。但设置了某些 mark 位之后, 流量就莫名其妙地在某些地方消失了,就像进入了黑洞,或者诸如此类的一些事情。这很可能是机器上运行的其他软件也在用 mark,和你的冲突了。

不幸的是,当前并没有一个权威机构能告诉你,哪些软件在使用 mark,以及它们是如何 使用的。因此,想要在自己的应用中设置 mark 字段时,如何通知到外界,以及如何确保不会与别人的 mark 冲突,就是一件很困难的事情,因为没有一个中心式的注册中心在管理 这些 mark —— 直到大约一个月之前,Dave 发了下面这条推文:

Dave 创建了这个 github repo,但注意,这里并不是教大家如何使用 mark,这也不是一个 决策机构,而只是一份文档,记录大家正在使用的 mark。如果你在用自己的 mark 方 案,强烈建议你记录到到这个 repo。

Cilium 网络

我来自 Cilium 团队,Cilium 是一个云原生网络方案,提供了众多的网络、可观测性和安全能力:

我们会用到 mark,比如处理 kube-proxy + Cilium 的兼容问题。

众多 CNCF 网络插件

为了准备这次分享,我还潜入云原生领域进行了诸多探索。下图是一些 CNCF 云原生网络插 件,它们多少都用到了 mark。

想了解具体的某个插件是如何使用 mark 的,可以找到它的源码,然后搜索 mark 关键字。

这里的一个考虑是:不同网络插件提供的功能可能是可叠加的。举个例子,如果你已经在用 flannel,然后又想用 Cilium 的可观测性和安全能力,那就可 以同时运行这两种网络插件 —— 显然,这里的前提是:Cilium 和 flannel 要对内核如 何处理包有一致的理解,这样才能确保 Cilium 沿某个路径转发包时,它们不会被丢弃( drop)。要做到这一点,Cilium 就需要理解包括 flannel 在内的一些组件是如何设置和使 用 mark 的。

下面我们就来看一些 mark 的典型使用场景。

2. 使用案例

这里整理了 7 个使用案例。我们会看到它们要完成各自的功能,分别需要使用 mark 中 的几个比特位。

网络策略(network policy)

第一个场景是网络安全策略,这是 K8s 不可或缺的组成部分。

这种场景用一个比特位就够了,可以表示两个值:

  • drop:白名单模式,默认全部 drop,显式配置 allow 列表
  • allow:黑名单模式,默认全部 allow,显式配置 drop 列表

默认 drop 模式

默认情况下,K8s 会自带一条 iptables 规则,drop 掉没有显式放行(allow)的流量

工作机制比较简单:

首先,在一条 iptables chain 中给经过的包打上一个 drop 标记(占用 skb->mark 中一个比特就够了),

(k8s node) $ iptables -t nat -L
...
Chain KUBE-MARK-DROP (0 references)
target     prot opt source     destination
MARK       all  --  anywhere   anywhere     MARK or 0x8000 # 所有经过这条规则的包执行:skb->mark |= 0x8000

稍后在另一条 chain 中检查这个标志位,如果仍然处于置位状态,就丢弃这个包:

(k8s node) $ iptables -L
Chain INPUT (policy ACCEPT)
target         prot opt source               destination
KUBE-FIREWALL  all  --  anywhere             anywhere  # 如果这条规则前面没有其他规则,就会跳转到下面的 KUBE-FIREWALL 

...

Chain KUBE-FIREWALL (2 references)
target     prot opt source               destination
DROP       all  --  anywhere             anywhere      # /* drop marked packets */ mark match 0x8000/0x8000

默认 allow 模式

这是白名单模式的变种:先给每个包打上允许通行(allow)标记,也是占用一个比特,稍 后再通过检查 skb->mark 有没有置位来决定是否放行。

另一个类似的场景是加解密:对需要加密的流量设置某些 mark,然后在执行加密的地方做检查,对设置了 skb->mark 的执行加密,没有设置的不执行。

通用处理模式

总结起来,这些场景的使用模式都是类似的:通过 packet mark 和 iptables 规则实现复杂的流量路径控制

  1. 在某个地方设置 skb->mark
  2. 在后面的一些地方检查 mark,然后根据匹配到的规则执行相应的操作,例如放行(allow)或者丢弃(drop),
  3. 在设置 mark 和解释 mark (前两步)之间,能够实现一些自己的处理逻辑。

典型场景:netfilter -> netfilter 流量过滤。

透明加密(transparent encryption)

加解密需要两个比特:一个加密标志位,一个解密标志位。

  • 常规的做法就是设置加密比特位,表示接下来需要对这个包做加密;或者设置解密位表示 要做解密。
  • 变种:有很多的可用秘钥,在 mark 存放要用的秘钥索引(index)。

典型场景:{ eBPF, netfilter } -> xfrm

Virtual IP Service(DNAT)

这种 Service 会有一个 VIP 作为入口,然后通过 DNAT 负载均衡到后端实例(backends)。

典型情况下,完成这个功能需要一或两个比特,设置之后来表示需要对这些包做 DNAT。但 严格来说,这不是唯一的实现方式。你也可以自己写一些逻辑来匹配目的 IP 和端口,然后 对匹配到的包执行 DNAT。

如果内核版本较老,那我们基于 eBPF 的 Service 实现可能会受限,此时就需 要与其他软件协同工作才能提供完整的 Service 功能。

我遇到过的一个场景是 OVS bridge(OVS -> routing -> OVS)。OVS 会设置一些 mark, 然后传给内核的策略路由模块,内核做策略路由之后再重新转发回 OVS,在 OVS 完成最终的 DNAT。

我遇到的最复杂的场景可能是 kube-router,我们会将 Service 信息写入内核,kube-router 会查看 Service 列表,提取三元组哈希成 30bit 写入 skb-mark,稍后内核里的 IPVS 再根据规则匹配这 些 mark 做某些负载均衡。

典型场景:{ eBPF, netfilter } -> netfilter

IP Masquerade(动态 SNAT)

和前面 Service/DNAT 类似,这里是设置某些比特位来做 SNAT。例如在前面某个地方设置 mark, 稍后在 IPVS 里检查这个 mark,然后通过 IP masquerade 做某些形式的负载均衡。

两个变种:

  1. 设置一个比特位,表示不要做 SNAT(1 bit, skip SNAT)。

    网络插件负责配置容器的网络连通性。但容器能否被集群外(或公网)访问就因插件 而异了。如果想让应用被公网访问,就需要通过某种方式配置一个公网 IP 地址。这里讨论的就是这种场景。

    典型情况下,此时仍然只需要一个 bit,表明不要对设置了 mark 的包做 SNAT(非公 网流量);没有设置 mark 包需要做 masquerade/SNAT,这些是公网流量。

    具体到 Cilium CNI plugin,可以在创建 pod 时声明带哪些 label 的 pod 在出集群时( egress)应当使用哪个特定的 源 IP 地址。例如,一台 node 上运行了三个应用的 pod,这些 pod 访问公网时,可以分别使用不同的 src ip 做 SNAT 到公网。

    上面的场景中,连接都是主动从 node 发起的,例如,node 内的应用主动访问公 网。实际中还有很多的连接是从外部发起的,目的端是 node 内的应用。例如,来 自 VPN 的访问 node 内应用的流量。

    这种情况下,如果将流量转发到本机协议栈网络,可以给它们打上一个 mark,表示要 做 SNAT。这样响应流量也会经过这个 node,然后沿着反向路径回到 VPN 客户端。

  2. 用 32bit,选择用哪个 SRC IP 做 SNAT/Masquerade。

典型场景:{eBPF, OVS, netfilter} -> netfilter

Multi-homing

非对称路径

这种场景在 AWS 环境中最常见。

背景信息:每个 AWS node(EC2)都有

  • 一个 primary 设备,提供了到外部的网络连通性,node 默认路由走这里
  • 多个 secondary 设备(node 默认路由不经过它们),每个设备上有多个独立的 IP 地 址,典型情况下是 8 个。在 node 上部署容器时,会从这些 secondary IP 地址中选择 一个来用。当 8 个地址用完之后,可以再分配一个 secondary device attach 到 EC2 (所以每个 EC2 都有多个网络)。

容器访问另一台 node 上的容器时,流量需要发送到对端容器所占用的那个 secondary 设备上。这里会用到源路由(source routing),也叫策略路由(policy routing)。默认情况下,策略路由的工作方式是:

  1. 首先匹配包的 SRC IP,选择对应的路由表,
  2. 然后在该路由表中再按 DST IP 匹配路由,
  3. 对于我们这里的场景,最终会匹配到经过某条 secondary device 的路由,然后通过这 个 secondary interface 将包发送出去。

当实现 Service 或类似功能时,对于接收端 node,主要有两种类型的流量:

  1. 从 secondary device 进来的、目的是本机容器的外部流量。也就是上面我们提到的流 量(Pod-to-Pod 流量);
  2. 从 primary device 进来的、目的是本机容器的流量(例如 NodePort Service 流量)。

对于第二种,不做特殊处理就会有问题:

  • 请求能正常从 primary device 进来,然后转发给容器,被容器正确处理,至此这里都没问题,
  • 但从上面的分析可知,如果没有额外处理,响应流量会从 secondary 设备发送到其所在 的网络。

导致的问题是:来的路径和回去的路径不一致(非对称路径),回包会被丢弃。

这里就是最经典地会用到 mark 的地方

  • 当流量从 primary 设备进来时,设置一个比特位,记录在连接跟踪的 mark(conntrack mark)中。
  • 当响应从 pod 发出时,查询连接跟踪记录。如果设置了这个 mark,就表明这个包需要从 主设备路由出去。

这样就解决了非对称路径的问题。

管理网与业务网分离:socket mark

另一个是 VPN 场景,每台 node 上可能会跑一个 management agent,负责配 置 VPN 网络。

这种情况下,肯定不能将管理网本身的流量也放到 VPN 网络。此时就可以用到 socket mark。这个状态会传递给路由层,在路由决策时使用。

这样做到了管理流量和 VPN 流量的分离。

典型:{ socket, netfilter } -> routing

应用身份(application identity)

Application identity (应用身份)用于网络层的访问控制。

在 Cilium 中,每个 endpoint 都对应一个 identityN:1),表示这个容器的安全 身份。Identity 主要用来实现 network policy,占用的比特数:

  • 一般用 16bit 表示(业界惯例),Cilium 单集群时也是这样,
  • 如果需要跨集群/多集群做安全策略,那这个 identity 会扩展到 24bit,多出来的 8bit 表示 cluster ID。

可以在出向(egress)和入向(ingress)做安全控制:

  • 如果 pod 想访问其他服务,可以在它的出向(egress)做策略,设置能访问和不能访问 哪些资源。如果没有设置任何策略,就会使用默认的 allow all 策略
  • 在接收端 pod 的入向(ingress)也可以做策略控制,过滤哪些源过来的允许访问,哪些不允许。

这里比较好的一点是:可以将 identity 以 mark 的方式打在每个包上,这样看到 identity 就知道了包的来源,因此安全策略的实现就可以变得简单:从包上提取 identity 和 IP、port 等信息,去查找有没有对应的放行策略就行了。

当与别的系统集成时,这里会变得更有意思。例如有个叫 portmap[2] 的 CNI 插件,可以做 CNI chaining,感兴趣可以去看看。集成时最大的问题是,无法保证在打标(mark)和检查 mark 之间会发生什么事情。

典型路径:{ eBPF, netfilter } -> routing -> eBPF

服务代理(service proxy)

这里的最后一个案例是服务代理(service proxy)。

Proxy 会终结来自客户端的请求,然后将其重定向到本机协议栈,随后请求被监听在本机协 议栈的服务(service)收起。

根据具体场景的不同,需要使用至少一个比特位:

  • 1 bit, route locally

    设置了这个比特位,就表示在本机做路由转发。

    我见过的大部分 service proxy 实际上只需要一个比特,但在实现上,有些却占用了整改 mark(16bit)。

  • 16 bit tproxy port towards proxy

    在老内核上,Cilium 会通过 mark 传递一个 16bit 的 tproxy port(从 eBPF 传递给  Netfilter 子系统),以此指定用哪个代理来转发流量。

  • 16+ bit Identity from proxy

    还可以通过 proxy 传递 identity。 这样就能够在处理 flow 的整个过程中保存这份状态(retain that state)。

典型路径:

  • eBPF -> { netfilter, routing }
  • netfilter -> routing
  • socket -> { eBPF, netfilter }

3. 思考、建议和挑战

这里讨论一些使用 mark 时的挑战,以及如何与其他网络应用互操作(interoperate), 因为多个网络应用可能在同时对内核网络栈进行编程(programming the stack)。

mark 使用方案设计

首先理解最简单问题:如果要开发一个会设置 skb->mark 的网络应用,那 如何分配 mark 中的每个比特?

有两种方式:

  1. 比特位方式:每个 bit 都有特定的语义。

    例如 32bit mark 能提供 32 个功能,每个功能都可以独立打开或关闭。因此,当有多个应用时,就可以说应用 A 使用这个比特,应用 B 使用另一个比特,合 理地分配这些比特空间。

    这种方式的一个问题是:最多只能提供 32 种功能

  2. Full mark 方式:将 mark 作为一个整形值。

    这样可以用到整个整形变量的空间,能提供的功能比前一种多的多。例如 32bit  可以提供 42 亿个不同的值。

根据我的观察,很多的软件在实现中都只使用了一个 bit,如果想做一些更疯狂和有趣的事 情,那需要将扩充到 32bit,然后在子系统之间传递这些信息。

比特位重载

如果你需要 60 个功能,那显然应该用 4 个比特位来编码这些功能,而不是使用 60 个独 立的比特位。但这种方式也有明显的限制:每个功能无法独立打开或关闭。因此用哪种方式 ,取决于你想和哪个子系统集成。

解决这个问题的另一种方式是:重载(overload)某些比特位

  • 例如,同样是最低 4bit,在 ingress 和 egress 上下文中,分别表示不同的含义。
  • 又如,根据包的地址范围来解释 mark 的含义,到某些地址范围的包,这些比特表示一种 意思;到其他地址范围的,表示另一种意思。

这显然带来了一些有趣的挑战。一旦开始 overload 这些 marks,理论上总能构建出 能与其他软件互操作的软件。

这个过程中,找到从哪里开始下手是很重要的。这就是我开始注意到前面提到的那些网络软 件的原因之一:因为所有这些工作最后都是与人打交道。例如, iptables 设置了第 15bit 表示 drop 的事实,意味着其他所有插件都要遵守 这些语义,并且其他人要避免使用这个 bit。这样当多个不同的网络插件或软件需要协 同工作来提供一组互补或增强的功能时,它们才不会彼此冲突。即,不同插件或软件之间要 对比特位的语义有一致的理解

对于 Cilium 来说,这是由我们的用户驱动的,如果用户已经使用了某些插件,并且希望在 这个插件之外同时运行 Cilium,我们就只能从寻求与这些插件的兼容开始。

发布和遵守 mark 方案

那么,我们该如何共享自己的使用方案呢?即,在与其他插件一起运行时,哪个比特位表示 什么意思,这些比特位提供哪些功能。

从网络应用角度来说,这里很重要的一点是:理清自己的功能,以及协同工作的他软件的 功能。例如,如果用户同时运行了 Cilium CNI 和另一个 CNI 插件,后者也提供了加 密功能;那从 Cilium 的角度来说,我们就无需开启自己的加密功能,让底层插件来做就行了 。

从现实的角度来说,

  • 让开发者遵守这些规范、理解它们是如何工作的,是一件有成本的事情;
  • 从复杂度的角度来说,如何管理和部署也是一件很有挑战的事情,因为更多的软件或插件 意味着更有可能出错,排障也会更加困难。本文主要关注在如何分配比特,如何定义语 义,如何与其他应用共享互操作。

深入理解网络栈

需要说明的是,在实际中,mark 并不是唯一软件的集成点(integration point)。

不同的插件可能都会插入 iptables 规则,匹配特定的目的地址、源地址等;甚至还可能 用 mark 来做新的策略路由,应用在不同的领域。

所以,如果你真要实现一个功能,能用到的信息其实不止是 32bit 的 mark,还可以用包头 中的字段、连接跟踪中的状态(conntrack status)等等。因此最终,你会坐下来研究网络 栈流程图,理解你的包是如何穿过 TC、eBPF 和 Netfilter 等的。

此外,还需要理解不同的软件、它们各自的机制,以及包经过这些不同的子系统时的不同路 径,这会因你启用的功能以及包的源和目的地址等而异。例如,这里最常见的场景之一是:请求流量是正常的,但响应流量却在某个地方消失了,最终发现是因 multi-home 问题被路 由到了不同的设备。

少即是多

如果有更多的 bit 可用,你会用来做什么

对于 Cilium 来说,我们正在积极探索用 eBPF 统一子系统之间的协作方式, 这样就可以避免在 eBPF 和 Netfilter、Conntrack 等子系统之间传递大量元数据了。如果能原生地在 eBPF 中实现处理逻辑,那就能使用 eBPF 领域的标准工具, 进而就能推理出包的转发路径等等,从而减少 mark 的使用。在这种方式下,和其他 软件集成就会轻松很多,因为我们并没有占用这些 mark。

当然,这并不是说只有 eBPF 能统一子系统之间的协作,你用 OVS、Netfilter 等等方式, 理论上也能统一。

另一个经常会讨论到的问题是:我们能否扩展 mark 空间?直接扩展 skb->mark 字 段我认为是太可能的。

  • 相比之下,添加一个 skb mark extension 之类的新字段,用这个字段做一些事情还是有 可能的,这样就有更多的通用比特(generic bits)来做事情。
  • 另一种方式是:将某些使用场景规范化。从通用空间中将某些 bits 拿出来,单独作为某 些场景的专用比特,定义它们的语义,这样它们之间的互操作就方便多了。但这种方式 会消耗一部分 mark 空间,留给其他网络应用的 mark 空间会变得更小。

4. 总结

最后总结,packet mark 是一种非常强大的机制,使我们能在不同子系统之间传递各种状态信息

另外,如何定义 mark 的语义,用户有很大的灵活性。当然,反面是如果你的软件想要 和其他网络软件协同工作,那必须事前约定,大家使用的 mark 不能有冲突, 并且彼此还要理解对方的语义(例子:kube-proxy + Cilium 场景)。这显然会带来很多的不确定性,当你试图实现某些新功能时,可能就会发现这个 mark 对我 来说很有用,但会不会和别的软件冲突,只有等实际部署到真实环境之后可能才会发现。很 可能直到这时你才会发现:原来这个 mark 已经被某个软件使用了、它的使用方式是这样的 、等等。

因此,我希望前面提到的 mark registry 能帮我们解决这个问题,希望大家将自己在用的 mark 以文档的方式集中到那个 repo。这也算是一个起点,由此我们就能知道,哪些应用的 mark 方式是和我的有冲突的。然后就能深入这个特定项目的源码,来看能否解决这些冲突 。

另外应该知道,mark 能提供的功能数,以及相应的场景数,要远远多于 mark 的比特数。关键在于你需要多少功能。

5. 相关链接

Cilium

  • https://cilium.io
  • https://cilium.io/slack
  • https://github.com/cilium/cilium
  • https://twitter.com/ciliumproject

Mark registry

  • https://github.com/fwmark/registry

参考资料

[1]

Packet Mark In a Cloud Native World: https://www.linuxplumbersconf.org/event/7/contributions/683/attachments/554/979/lpc20-pkt-mark-slides.pdf

[2]

portmap: https://www.cni.dev/plugins/meta/portmap/


原文链接:http://arthurchiao.art/blog/packet-mark-in-a-cloud-native-world-zh/



你可能还喜欢

点击下方图片即可阅读

Containerd 的前世今生和保姆级入门教程

云原生是一种信仰 🤘



码关注公众号

后台回复◉k8s◉获取史上最方便快捷的 Kubernetes 高可用部署工具,只需一条命令,连 ssh 都不需要!



点击 "阅读原文" 获取更好的阅读体验!

❤️给个「在看」,是对我最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存